@micropage-sh/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -0
- package/dist/annotations.d.ts +20 -0
- package/dist/annotations.js +36 -0
- package/dist/annotations.js.map +1 -0
- package/dist/client/assets.d.ts +85 -0
- package/dist/client/assets.js +473 -0
- package/dist/client/assets.js.map +1 -0
- package/dist/client/auth-provider.d.ts +24 -0
- package/dist/client/auth-provider.js +10 -0
- package/dist/client/auth-provider.js.map +1 -0
- package/dist/client/builds.d.ts +82 -0
- package/dist/client/builds.js +166 -0
- package/dist/client/builds.js.map +1 -0
- package/dist/client/config.d.ts +18 -0
- package/dist/client/config.js +29 -0
- package/dist/client/config.js.map +1 -0
- package/dist/client/deploy-events.d.ts +98 -0
- package/dist/client/deploy-events.js +170 -0
- package/dist/client/deploy-events.js.map +1 -0
- package/dist/client/deploy-token.d.ts +48 -0
- package/dist/client/deploy-token.js +130 -0
- package/dist/client/deploy-token.js.map +1 -0
- package/dist/client/errors.d.ts +18 -0
- package/dist/client/errors.js +21 -0
- package/dist/client/errors.js.map +1 -0
- package/dist/client/http.d.ts +68 -0
- package/dist/client/http.js +191 -0
- package/dist/client/http.js.map +1 -0
- package/dist/client/jwt.d.ts +13 -0
- package/dist/client/jwt.js +23 -0
- package/dist/client/jwt.js.map +1 -0
- package/dist/client/lock.d.ts +16 -0
- package/dist/client/lock.js +67 -0
- package/dist/client/lock.js.map +1 -0
- package/dist/client/pages.d.ts +47 -0
- package/dist/client/pages.js +131 -0
- package/dist/client/pages.js.map +1 -0
- package/dist/client/posts.d.ts +108 -0
- package/dist/client/posts.js +187 -0
- package/dist/client/posts.js.map +1 -0
- package/dist/client/project-ref.d.ts +68 -0
- package/dist/client/project-ref.js +118 -0
- package/dist/client/project-ref.js.map +1 -0
- package/dist/client/session-store.d.ts +81 -0
- package/dist/client/session-store.js +271 -0
- package/dist/client/session-store.js.map +1 -0
- package/dist/client/submissions.d.ts +59 -0
- package/dist/client/submissions.js +111 -0
- package/dist/client/submissions.js.map +1 -0
- package/dist/client/tier.d.ts +42 -0
- package/dist/client/tier.js +76 -0
- package/dist/client/tier.js.map +1 -0
- package/dist/content/index.d.ts +5 -0
- package/dist/content/index.js +28 -0
- package/dist/content/index.js.map +1 -0
- package/dist/context.d.ts +30 -0
- package/dist/context.js +2 -0
- package/dist/context.js.map +1 -0
- package/dist/guards.d.ts +55 -0
- package/dist/guards.js +168 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/progress.d.ts +7 -0
- package/dist/progress.js +25 -0
- package/dist/progress.js.map +1 -0
- package/dist/reference/prompts.d.ts +6 -0
- package/dist/reference/prompts.js +110 -0
- package/dist/reference/prompts.js.map +1 -0
- package/dist/reference/resources.d.ts +2 -0
- package/dist/reference/resources.js +41 -0
- package/dist/reference/resources.js.map +1 -0
- package/dist/reference/tool.d.ts +21 -0
- package/dist/reference/tool.js +50 -0
- package/dist/reference/tool.js.map +1 -0
- package/dist/reference/topics.d.ts +29 -0
- package/dist/reference/topics.js +91 -0
- package/dist/reference/topics.js.map +1 -0
- package/dist/reference.d.ts +9 -0
- package/dist/reference.js +20 -0
- package/dist/reference.js.map +1 -0
- package/dist/server.d.ts +29 -0
- package/dist/server.js +75 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/account.d.ts +34 -0
- package/dist/tools/account.js +128 -0
- package/dist/tools/account.js.map +1 -0
- package/dist/tools/builds.d.ts +95 -0
- package/dist/tools/builds.js +403 -0
- package/dist/tools/builds.js.map +1 -0
- package/dist/tools/files.d.ts +71 -0
- package/dist/tools/files.js +205 -0
- package/dist/tools/files.js.map +1 -0
- package/dist/tools/forms.d.ts +59 -0
- package/dist/tools/forms.js +177 -0
- package/dist/tools/forms.js.map +1 -0
- package/dist/tools/posts.d.ts +191 -0
- package/dist/tools/posts.js +600 -0
- package/dist/tools/posts.js.map +1 -0
- package/dist/tools/projects.d.ts +201 -0
- package/dist/tools/projects.js +553 -0
- package/dist/tools/projects.js.map +1 -0
- package/dist/tools/shared.d.ts +33 -0
- package/dist/tools/shared.js +41 -0
- package/dist/tools/shared.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cosmin Stefaniga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# @micropage-sh/mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [micropage.sh](https://micropage.sh). It lets an AI assistant (Claude Code, Claude Desktop, Cursor, VS Code) create, edit and publish micropages and posts from markup.
|
|
4
|
+
|
|
5
|
+
**Documentation: https://docs.micropage.sh/docs/mcp/overview/**
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
The server needs Node.js 20 or later and a Pro or Pro+ account. It uses the session from the [micropage CLI](https://www.npmjs.com/package/micropage):
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install -g @micropage-sh/cli
|
|
13
|
+
micropage login
|
|
14
|
+
claude mcp add micropage -- npx -y @micropage-sh/mcp
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
For other clients, run `npx -y @micropage-sh/mcp` over stdio:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"micropage": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "@micropage-sh/mcp"]
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Emailing subscribers, deleting projects and reading form submissions are off by default. Turn them on with `MICROPAGE_MCP_ALLOW_SEND=1`, `MICROPAGE_MCP_ALLOW_DELETE=1` and `MICROPAGE_MCP_SUBMISSIONS=1`. For headless use, set `MICROPAGE_DEPLOY_TOKEN` and `MICROPAGE_DEPLOY_PROJECT` instead of logging in. See [Install](https://docs.micropage.sh/docs/mcp/install/) and [Safety](https://docs.micropage.sh/docs/mcp/safety/).
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
See [DEVELOPMENT.md](DEVELOPMENT.md) for working on the server and [PUBLISHING.md](PUBLISHING.md) for releases.
|
|
35
|
+
|
|
36
|
+
## License
|
|
37
|
+
|
|
38
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ToolAnnotations } from "@modelcontextprotocol/server";
|
|
2
|
+
/**
|
|
3
|
+
* Annotation presets. Every preset sets all four hints explicitly: the spec
|
|
4
|
+
* defaults an absent destructiveHint to true and an absent openWorldHint to
|
|
5
|
+
* true, so leaving one out silently changes how hosts prompt.
|
|
6
|
+
*/
|
|
7
|
+
export type ToolHints = Required<Pick<ToolAnnotations, "readOnlyHint" | "destructiveHint" | "idempotentHint" | "openWorldHint">>;
|
|
8
|
+
/** Reads micropage state; never changes anything. */
|
|
9
|
+
export declare const RO: Readonly<ToolHints>;
|
|
10
|
+
/** Writes drafts or files nobody outside the account sees yet. */
|
|
11
|
+
export declare const WRITE: Readonly<ToolHints>;
|
|
12
|
+
/** Overwrites or removes existing state (including a live post's content). */
|
|
13
|
+
export declare const DESTRUCTIVE: Readonly<ToolHints>;
|
|
14
|
+
/**
|
|
15
|
+
* Outward-facing: changes what the public sees or sends email. Same wire
|
|
16
|
+
* hints as DESTRUCTIVE, kept as its own preset so code and tests can tell
|
|
17
|
+
* the tools that also require a server-checked confirmation apart.
|
|
18
|
+
*/
|
|
19
|
+
export declare const OUT: Readonly<ToolHints>;
|
|
20
|
+
export declare function hints(base: Readonly<ToolHints>, overrides?: Partial<ToolHints>): ToolHints;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Reads micropage state; never changes anything. */
|
|
2
|
+
export const RO = Object.freeze({
|
|
3
|
+
readOnlyHint: true,
|
|
4
|
+
destructiveHint: false,
|
|
5
|
+
idempotentHint: true,
|
|
6
|
+
openWorldHint: true,
|
|
7
|
+
});
|
|
8
|
+
/** Writes drafts or files nobody outside the account sees yet. */
|
|
9
|
+
export const WRITE = Object.freeze({
|
|
10
|
+
readOnlyHint: false,
|
|
11
|
+
destructiveHint: false,
|
|
12
|
+
idempotentHint: false,
|
|
13
|
+
openWorldHint: true,
|
|
14
|
+
});
|
|
15
|
+
/** Overwrites or removes existing state (including a live post's content). */
|
|
16
|
+
export const DESTRUCTIVE = Object.freeze({
|
|
17
|
+
readOnlyHint: false,
|
|
18
|
+
destructiveHint: true,
|
|
19
|
+
idempotentHint: false,
|
|
20
|
+
openWorldHint: true,
|
|
21
|
+
});
|
|
22
|
+
/**
|
|
23
|
+
* Outward-facing: changes what the public sees or sends email. Same wire
|
|
24
|
+
* hints as DESTRUCTIVE, kept as its own preset so code and tests can tell
|
|
25
|
+
* the tools that also require a server-checked confirmation apart.
|
|
26
|
+
*/
|
|
27
|
+
export const OUT = Object.freeze({
|
|
28
|
+
readOnlyHint: false,
|
|
29
|
+
destructiveHint: true,
|
|
30
|
+
idempotentHint: false,
|
|
31
|
+
openWorldHint: true,
|
|
32
|
+
});
|
|
33
|
+
export function hints(base, overrides = {}) {
|
|
34
|
+
return { ...base, ...overrides };
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=annotations.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"annotations.js","sourceRoot":"","sources":["../src/annotations.ts"],"names":[],"mappings":"AAWA,qDAAqD;AACrD,MAAM,CAAC,MAAM,EAAE,GAAwB,MAAM,CAAC,MAAM,CAAC;IACnD,YAAY,EAAE,IAAI;IAClB,eAAe,EAAE,KAAK;IACtB,cAAc,EAAE,IAAI;IACpB,aAAa,EAAE,IAAI;CACpB,CAAC,CAAC;AAEH,kEAAkE;AAClE,MAAM,CAAC,MAAM,KAAK,GAAwB,MAAM,CAAC,MAAM,CAAC;IACtD,YAAY,EAAE,KAAK;IACnB,eAAe,EAAE,KAAK;IACtB,cAAc,EAAE,KAAK;IACrB,aAAa,EAAE,IAAI;CACpB,CAAC,CAAC;AAEH,8EAA8E;AAC9E,MAAM,CAAC,MAAM,WAAW,GAAwB,MAAM,CAAC,MAAM,CAAC;IAC5D,YAAY,EAAE,KAAK;IACnB,eAAe,EAAE,IAAI;IACrB,cAAc,EAAE,KAAK;IACrB,aAAa,EAAE,IAAI;CACpB,CAAC,CAAC;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,GAAG,GAAwB,MAAM,CAAC,MAAM,CAAC;IACpD,YAAY,EAAE,KAAK;IACnB,eAAe,EAAE,IAAI;IACrB,cAAc,EAAE,KAAK;IACrB,aAAa,EAAE,IAAI;CACpB,CAAC,CAAC;AAEH,MAAM,UAAU,KAAK,CAAC,IAAyB,EAAE,YAAgC,EAAE;IACjF,OAAO,EAAE,GAAG,IAAI,EAAE,GAAG,SAAS,EAAE,CAAC;AACnC,CAAC"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { FetchLike, Http } from "./http.js";
|
|
2
|
+
/** Same extensions and types as the CLI (cli/src/mime.js); upload-file stores whatever type it is sent. */
|
|
3
|
+
export declare const ASSET_MIME_TYPES: Readonly<Record<string, string>>;
|
|
4
|
+
export declare const MAX_ASSET_BYTES: number;
|
|
5
|
+
export declare const URL_FETCH_TIMEOUT_MS = 20000;
|
|
6
|
+
/**
|
|
7
|
+
* The stored filename is what `.page` markup references (`img: <- logo.png`),
|
|
8
|
+
* so it must be a bare name with an extension the CLI would upload.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateAssetFilename(raw: string): string;
|
|
11
|
+
export declare function mimeTypeFor(filename: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* True when the document's root element is <svg>. Only an optional BOM,
|
|
14
|
+
* whitespace, one <?xml ...?> declaration, comments and a <!DOCTYPE svg ...>
|
|
15
|
+
* (without an internal subset) may come before it. Looking for "<svg"
|
|
16
|
+
* anywhere would pass an HTML page with an inline icon, or any file that
|
|
17
|
+
* merely mentions <svg> in a comment.
|
|
18
|
+
*/
|
|
19
|
+
export declare function hasSvgRoot(text: string): boolean;
|
|
20
|
+
export declare function assertContentMatchesExtension(filename: string, bytes: Uint8Array): void;
|
|
21
|
+
export type AssetSource = {
|
|
22
|
+
path: string;
|
|
23
|
+
} | {
|
|
24
|
+
url: string;
|
|
25
|
+
} | {
|
|
26
|
+
base64: string;
|
|
27
|
+
};
|
|
28
|
+
export interface LoadSourceOptions {
|
|
29
|
+
/** Used for `{url}` sources only; never sent micropage credentials. */
|
|
30
|
+
fetch?: FetchLike;
|
|
31
|
+
/** Resolves `{url}` hostnames before each fetch; defaults to urlHostLookup.current. */
|
|
32
|
+
lookup?: HostLookup;
|
|
33
|
+
maxBytes?: number;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
}
|
|
36
|
+
export declare function loadAssetSource(source: AssetSource, options?: LoadSourceOptions): Promise<Buffer>;
|
|
37
|
+
export type HostLookup = (hostname: string) => Promise<ReadonlyArray<{
|
|
38
|
+
address: string;
|
|
39
|
+
family: number;
|
|
40
|
+
}>>;
|
|
41
|
+
/** Swappable in tests so no real DNS query is made. */
|
|
42
|
+
export declare const urlHostLookup: {
|
|
43
|
+
current: HostLookup;
|
|
44
|
+
};
|
|
45
|
+
export declare const MAX_URL_REDIRECTS = 5;
|
|
46
|
+
/** True for loopback, private, link-local, CGNAT, ULA, multicast and unspecified addresses, in any notation. */
|
|
47
|
+
export declare function isPrivateAddress(ip: string): boolean;
|
|
48
|
+
export declare function sha256Hex(bytes: Uint8Array): string;
|
|
49
|
+
export interface UserFile {
|
|
50
|
+
id: string;
|
|
51
|
+
filename: string | null;
|
|
52
|
+
mime_type: string | null;
|
|
53
|
+
size_bytes: number | null;
|
|
54
|
+
content_hash: string | null;
|
|
55
|
+
created_at: string | null;
|
|
56
|
+
}
|
|
57
|
+
export interface FileListing {
|
|
58
|
+
files: UserFile[];
|
|
59
|
+
total_bytes: number;
|
|
60
|
+
/** Plan storage limit (upload-file's spaceMbForPlan). */
|
|
61
|
+
space_available_mb: number;
|
|
62
|
+
}
|
|
63
|
+
export declare function listFiles(http: Http, projectId: number): Promise<FileListing>;
|
|
64
|
+
export declare function getFileUrl(http: Http, fileId: string): Promise<string>;
|
|
65
|
+
export declare function deleteFile(http: Http, fileId: string): Promise<void>;
|
|
66
|
+
/** Multipart body exactly as the CLI's uploadAssetWithToken builds it. */
|
|
67
|
+
export declare function buildUploadForm(projectId: number, filename: string, bytes: Uint8Array, contentHash: string): FormData;
|
|
68
|
+
export declare function uploadFile(http: Http, projectId: number, filename: string, bytes: Uint8Array, contentHash: string): Promise<UserFile>;
|
|
69
|
+
export interface UploadAssetResult {
|
|
70
|
+
file: UserFile;
|
|
71
|
+
url: string;
|
|
72
|
+
/** Same name and same SHA-256 already stored: nothing was uploaded. */
|
|
73
|
+
deduped: boolean;
|
|
74
|
+
/** A file with this name but different (or unknown) content was deleted and replaced. */
|
|
75
|
+
replaced: boolean;
|
|
76
|
+
content_hash: string;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The CLI's per-file dedupe (uploadAssetsWithToken): one list-files call;
|
|
80
|
+
* same name + same content_hash is skipped; a changed or NULL hash (rows from
|
|
81
|
+
* before hashing) is deleted and re-uploaded with content_hash. A list-files
|
|
82
|
+
* failure is fatal, as in the CLI, since uploading blind would leave
|
|
83
|
+
* duplicate rows under one name.
|
|
84
|
+
*/
|
|
85
|
+
export declare function uploadAsset(http: Http, projectId: number, filename: string, bytes: Uint8Array): Promise<UploadAssetResult>;
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import { extname, isAbsolute } from "node:path";
|
|
6
|
+
import { VERSION } from "../version.js";
|
|
7
|
+
import { MicropageError, isMicropageError } from "./errors.js";
|
|
8
|
+
/** Same extensions and types as the CLI (cli/src/mime.js); upload-file stores whatever type it is sent. */
|
|
9
|
+
export const ASSET_MIME_TYPES = Object.freeze({
|
|
10
|
+
".png": "image/png",
|
|
11
|
+
".jpg": "image/jpeg",
|
|
12
|
+
".jpeg": "image/jpeg",
|
|
13
|
+
".gif": "image/gif",
|
|
14
|
+
".webp": "image/webp",
|
|
15
|
+
".ico": "image/x-icon",
|
|
16
|
+
".svg": "image/svg+xml",
|
|
17
|
+
});
|
|
18
|
+
export const MAX_ASSET_BYTES = 10 * 1024 * 1024;
|
|
19
|
+
export const URL_FETCH_TIMEOUT_MS = 20_000;
|
|
20
|
+
const MB = 1024 * 1024;
|
|
21
|
+
const formatMb = (bytes) => `${(bytes / MB).toFixed(bytes < 10 * MB ? 2 : 1)} MB`;
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Filename
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* The stored filename is what `.page` markup references (`img: <- logo.png`),
|
|
27
|
+
* so it must be a bare name with an extension the CLI would upload.
|
|
28
|
+
*/
|
|
29
|
+
export function validateAssetFilename(raw) {
|
|
30
|
+
const name = raw.trim();
|
|
31
|
+
const allowed = Object.keys(ASSET_MIME_TYPES).join(", ");
|
|
32
|
+
if (!name || name === "." || name === "..") {
|
|
33
|
+
throw new MicropageError("INVALID_FILENAME", "filename is empty. Pass a bare name such as \"logo.png\".");
|
|
34
|
+
}
|
|
35
|
+
if (/[/\\]/.test(name)) {
|
|
36
|
+
throw new MicropageError("INVALID_FILENAME", `filename "${raw}" contains a path separator. Pass the bare name only (e.g. "logo.png"); the source goes in \`source\`.`);
|
|
37
|
+
}
|
|
38
|
+
// eslint-disable-next-line no-control-regex
|
|
39
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) {
|
|
40
|
+
throw new MicropageError("INVALID_FILENAME", "filename contains control characters. Use a plain name such as \"logo.png\".");
|
|
41
|
+
}
|
|
42
|
+
if (name.length > 200) {
|
|
43
|
+
throw new MicropageError("INVALID_FILENAME", "filename is longer than 200 characters. Use a shorter name.");
|
|
44
|
+
}
|
|
45
|
+
const ext = extname(name).toLowerCase();
|
|
46
|
+
if (!ext || !(ext in ASSET_MIME_TYPES) || name.toLowerCase() === ext) {
|
|
47
|
+
throw new MicropageError("UNSUPPORTED_FILE_TYPE", `"${name}" is not a supported asset type. micropage assets must be images with one of these extensions: ${allowed}. ` +
|
|
48
|
+
"Convert the file (e.g. to .png or .webp) and upload it under a matching name.");
|
|
49
|
+
}
|
|
50
|
+
return name;
|
|
51
|
+
}
|
|
52
|
+
export function mimeTypeFor(filename) {
|
|
53
|
+
return ASSET_MIME_TYPES[extname(filename).toLowerCase()] ?? "application/octet-stream";
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Content check
|
|
57
|
+
//
|
|
58
|
+
// The bytes must actually be the image type the extension claims. Besides
|
|
59
|
+
// catching honest mistakes, this keeps a prompt-injected "upload ~/.ssh/id_rsa
|
|
60
|
+
// as key.png" from publishing a local secret to a public bucket.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
const startsWith = (bytes, sig, offset = 0) => bytes.length >= offset + sig.length && sig.every((b, i) => bytes[offset + i] === b);
|
|
63
|
+
const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
|
|
64
|
+
function looksLike(ext, bytes) {
|
|
65
|
+
switch (ext) {
|
|
66
|
+
case ".png":
|
|
67
|
+
return startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
68
|
+
case ".jpg":
|
|
69
|
+
case ".jpeg":
|
|
70
|
+
return startsWith(bytes, [0xff, 0xd8, 0xff]);
|
|
71
|
+
case ".gif":
|
|
72
|
+
return startsWith(bytes, ascii("GIF87a")) || startsWith(bytes, ascii("GIF89a"));
|
|
73
|
+
case ".webp":
|
|
74
|
+
return startsWith(bytes, ascii("RIFF")) && startsWith(bytes, ascii("WEBP"), 8);
|
|
75
|
+
case ".ico":
|
|
76
|
+
return startsWith(bytes, [0x00, 0x00, 0x01, 0x00]);
|
|
77
|
+
case ".svg":
|
|
78
|
+
return hasSvgRoot(Buffer.from(bytes).toString("utf8"));
|
|
79
|
+
default:
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* True when the document's root element is <svg>. Only an optional BOM,
|
|
85
|
+
* whitespace, one <?xml ...?> declaration, comments and a <!DOCTYPE svg ...>
|
|
86
|
+
* (without an internal subset) may come before it. Looking for "<svg"
|
|
87
|
+
* anywhere would pass an HTML page with an inline icon, or any file that
|
|
88
|
+
* merely mentions <svg> in a comment.
|
|
89
|
+
*/
|
|
90
|
+
export function hasSvgRoot(text) {
|
|
91
|
+
let rest = text.replace(/^\uFEFF/, "");
|
|
92
|
+
let sawDecl = false;
|
|
93
|
+
let sawDoctype = false;
|
|
94
|
+
for (;;) {
|
|
95
|
+
rest = rest.trimStart();
|
|
96
|
+
if (/^<\?xml[\s?]/.test(rest) && !sawDecl) {
|
|
97
|
+
const end = rest.indexOf("?>");
|
|
98
|
+
if (end < 0)
|
|
99
|
+
return false;
|
|
100
|
+
rest = rest.slice(end + 2);
|
|
101
|
+
sawDecl = true;
|
|
102
|
+
}
|
|
103
|
+
else if (rest.startsWith("<!--")) {
|
|
104
|
+
const end = rest.indexOf("-->", 4);
|
|
105
|
+
if (end < 0)
|
|
106
|
+
return false;
|
|
107
|
+
rest = rest.slice(end + 3);
|
|
108
|
+
}
|
|
109
|
+
else if (/^<!DOCTYPE\s+svg[\s>]/.test(rest) && !sawDoctype) {
|
|
110
|
+
const end = rest.indexOf(">");
|
|
111
|
+
// An internal subset ([...]) can declare entities; no real SVG needs one.
|
|
112
|
+
if (end < 0 || rest.slice(0, end).includes("["))
|
|
113
|
+
return false;
|
|
114
|
+
rest = rest.slice(end + 1);
|
|
115
|
+
sawDoctype = true;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
return /^<svg[\s>/]/.test(rest);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function assertContentMatchesExtension(filename, bytes) {
|
|
123
|
+
if (bytes.length === 0) {
|
|
124
|
+
throw new MicropageError("INVALID_ASSET", "The asset is empty (0 bytes). Nothing was uploaded.");
|
|
125
|
+
}
|
|
126
|
+
const ext = extname(filename).toLowerCase();
|
|
127
|
+
if (!looksLike(ext, bytes)) {
|
|
128
|
+
throw new MicropageError("INVALID_ASSET", `The content is not a valid ${ext.slice(1).toUpperCase()} image, so it does not match "${filename}". ` +
|
|
129
|
+
"Check the source, or rename the file to its real image type. Nothing was uploaded.");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function tooLarge(size, maxBytes) {
|
|
133
|
+
const shown = typeof size === "number" ? formatMb(size) : size;
|
|
134
|
+
return new MicropageError("ASSET_TOO_LARGE", `The asset is ${shown}; the limit is ${formatMb(maxBytes)}. Resize or recompress it (e.g. to WebP) and retry. Nothing was uploaded.`);
|
|
135
|
+
}
|
|
136
|
+
export async function loadAssetSource(source, options = {}) {
|
|
137
|
+
const maxBytes = options.maxBytes ?? MAX_ASSET_BYTES;
|
|
138
|
+
if ("path" in source)
|
|
139
|
+
return loadPath(source.path, maxBytes);
|
|
140
|
+
if ("url" in source) {
|
|
141
|
+
return loadUrl(source.url, {
|
|
142
|
+
maxBytes,
|
|
143
|
+
timeoutMs: options.timeoutMs ?? URL_FETCH_TIMEOUT_MS,
|
|
144
|
+
fetchImpl: options.fetch ?? ((i, init) => fetch(i, init)),
|
|
145
|
+
lookup: options.lookup ?? urlHostLookup.current,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return loadBase64(source.base64, maxBytes);
|
|
149
|
+
}
|
|
150
|
+
async function loadPath(path, maxBytes) {
|
|
151
|
+
if (!isAbsolute(path)) {
|
|
152
|
+
throw new MicropageError("INVALID_SOURCE", `source.path "${path}" is relative. Pass an absolute path; the server's working directory is not the user's.`);
|
|
153
|
+
}
|
|
154
|
+
let info;
|
|
155
|
+
try {
|
|
156
|
+
info = await stat(path);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
throw new MicropageError("INVALID_SOURCE", `Cannot read source.path "${path}": ${err.code ?? String(err)}.`, {
|
|
160
|
+
cause: err,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
if (!info.isFile())
|
|
164
|
+
throw new MicropageError("INVALID_SOURCE", `source.path "${path}" is not a regular file.`);
|
|
165
|
+
if (info.size > maxBytes)
|
|
166
|
+
throw tooLarge(info.size, maxBytes);
|
|
167
|
+
const bytes = await readFile(path);
|
|
168
|
+
if (bytes.length > maxBytes)
|
|
169
|
+
throw tooLarge(bytes.length, maxBytes);
|
|
170
|
+
return bytes;
|
|
171
|
+
}
|
|
172
|
+
function loadBase64(raw, maxBytes) {
|
|
173
|
+
// Accept a data: URL as well as bare base64, and ignore line wrapping.
|
|
174
|
+
const body = raw.replace(/^data:[^,]*;base64,/i, "").replace(/\s+/g, "");
|
|
175
|
+
if (!body || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(body)) {
|
|
176
|
+
throw new MicropageError("INVALID_SOURCE", "source.base64 is not valid base64. Pass the file bytes base64-encoded (a data: URL is fine).");
|
|
177
|
+
}
|
|
178
|
+
// Checked before decoding so an oversized argument is never materialized twice.
|
|
179
|
+
if (Math.floor((body.length * 3) / 4) > maxBytes + 2)
|
|
180
|
+
throw tooLarge(Math.floor((body.length * 3) / 4), maxBytes);
|
|
181
|
+
// Node's base64 decoder also accepts the URL-safe alphabet.
|
|
182
|
+
const bytes = Buffer.from(body, "base64");
|
|
183
|
+
if (bytes.length > maxBytes)
|
|
184
|
+
throw tooLarge(bytes.length, maxBytes);
|
|
185
|
+
return bytes;
|
|
186
|
+
}
|
|
187
|
+
/** Swappable in tests so no real DNS query is made. */
|
|
188
|
+
export const urlHostLookup = {
|
|
189
|
+
current: (hostname) => lookup(hostname, { all: true, verbatim: true }),
|
|
190
|
+
};
|
|
191
|
+
export const MAX_URL_REDIRECTS = 5;
|
|
192
|
+
function ipv4Bytes(ip) {
|
|
193
|
+
const parts = ip.split(".");
|
|
194
|
+
if (parts.length !== 4)
|
|
195
|
+
return null;
|
|
196
|
+
const bytes = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : Number.NaN));
|
|
197
|
+
return bytes.every((b) => b >= 0 && b <= 255) ? bytes : null;
|
|
198
|
+
}
|
|
199
|
+
/** 16 bytes of an IPv6 address (zone id dropped), or null when it does not parse. */
|
|
200
|
+
function ipv6Bytes(raw) {
|
|
201
|
+
let ip = raw.split("%")[0];
|
|
202
|
+
let tail = [];
|
|
203
|
+
const lastColon = ip.lastIndexOf(":");
|
|
204
|
+
if (ip.slice(lastColon + 1).includes(".")) {
|
|
205
|
+
const v4 = ipv4Bytes(ip.slice(lastColon + 1));
|
|
206
|
+
if (!v4)
|
|
207
|
+
return null;
|
|
208
|
+
tail = v4;
|
|
209
|
+
ip = `${ip.slice(0, lastColon + 1)}0:0`;
|
|
210
|
+
}
|
|
211
|
+
const halves = ip.split("::");
|
|
212
|
+
if (halves.length > 2)
|
|
213
|
+
return null;
|
|
214
|
+
const groups = (h) => (h === "" ? [] : h.split(":"));
|
|
215
|
+
const head = groups(halves[0]);
|
|
216
|
+
const back = halves.length === 2 ? groups(halves[1]) : [];
|
|
217
|
+
const fill = 8 - head.length - back.length;
|
|
218
|
+
if (halves.length === 1 ? fill !== 0 : fill < 1)
|
|
219
|
+
return null;
|
|
220
|
+
const all = [...head, ...Array(halves.length === 2 ? fill : 0).fill("0"), ...back];
|
|
221
|
+
const bytes = [];
|
|
222
|
+
for (const g of all) {
|
|
223
|
+
if (!/^[0-9a-f]{1,4}$/i.test(g))
|
|
224
|
+
return null;
|
|
225
|
+
const n = parseInt(g, 16);
|
|
226
|
+
bytes.push(n >> 8, n & 0xff);
|
|
227
|
+
}
|
|
228
|
+
if (tail.length)
|
|
229
|
+
bytes.splice(12, 4, ...tail);
|
|
230
|
+
return bytes;
|
|
231
|
+
}
|
|
232
|
+
function blockedV4([a, b]) {
|
|
233
|
+
return (a === 0 || // 0.0.0.0/8 "this network"
|
|
234
|
+
a === 10 ||
|
|
235
|
+
a === 127 ||
|
|
236
|
+
(a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
|
|
237
|
+
(a === 169 && b === 254) || // link-local, cloud metadata
|
|
238
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
239
|
+
(a === 192 && b === 168) ||
|
|
240
|
+
a >= 224 // multicast 224/4 and reserved 240/4, incl. broadcast
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
/** True for loopback, private, link-local, CGNAT, ULA, multicast and unspecified addresses, in any notation. */
|
|
244
|
+
export function isPrivateAddress(ip) {
|
|
245
|
+
const family = isIP(ip.split("%")[0]);
|
|
246
|
+
if (family === 4)
|
|
247
|
+
return blockedV4(ipv4Bytes(ip));
|
|
248
|
+
if (family !== 6)
|
|
249
|
+
return true;
|
|
250
|
+
const b = ipv6Bytes(ip);
|
|
251
|
+
if (!b)
|
|
252
|
+
return true;
|
|
253
|
+
const zeros = (n) => b.slice(0, n).every((x) => x === 0);
|
|
254
|
+
// ::ffff:a.b.c.d (mapped) and ::a.b.c.d (compatible, which also covers :: and ::1).
|
|
255
|
+
if ((zeros(10) && b[10] === 0xff && b[11] === 0xff) || zeros(12))
|
|
256
|
+
return blockedV4(b.slice(12));
|
|
257
|
+
return ((b[0] === 0xfe && (b[1] & 0xc0) === 0x80) || // fe80::/10 link-local
|
|
258
|
+
(b[0] & 0xfe) === 0xfc || // fc00::/7 unique local
|
|
259
|
+
b[0] === 0xff // ff00::/8 multicast
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
async function assertPublicHost(url, lookupHost, redirected) {
|
|
263
|
+
if (url.protocol !== "https:") {
|
|
264
|
+
throw new MicropageError("INVALID_SOURCE", redirected
|
|
265
|
+
? `source.url redirected to a non-https URL (${url.toString()}). Nothing was uploaded.`
|
|
266
|
+
: `source.url must use https:// (got ${url.protocol}//). Nothing was fetched.`);
|
|
267
|
+
}
|
|
268
|
+
const host = url.hostname.replace(/^\[|\]$/g, "");
|
|
269
|
+
const refuse = (address) => new MicropageError("INVALID_SOURCE", `source.url host ${url.hostname} is a private, local or reserved address (${address}). Only public https hosts are ` +
|
|
270
|
+
"fetched; upload a local file with source.path or source.base64 instead. Nothing was fetched.");
|
|
271
|
+
if (isIP(host)) {
|
|
272
|
+
if (isPrivateAddress(host))
|
|
273
|
+
throw refuse(host);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
let addresses;
|
|
277
|
+
try {
|
|
278
|
+
addresses = await lookupHost(host);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
throw new MicropageError("NETWORK", `Could not resolve ${host}: ${err instanceof Error ? err.message : String(err)}. Check the URL.`, {
|
|
282
|
+
cause: err,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (addresses.length === 0)
|
|
286
|
+
throw new MicropageError("NETWORK", `${host} has no addresses. Check the URL.`);
|
|
287
|
+
const bad = addresses.find((a) => isPrivateAddress(a.address));
|
|
288
|
+
if (bad)
|
|
289
|
+
throw refuse(bad.address);
|
|
290
|
+
}
|
|
291
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
292
|
+
async function loadUrl(raw, { maxBytes, timeoutMs, fetchImpl, lookup: lookupHost }) {
|
|
293
|
+
let url;
|
|
294
|
+
try {
|
|
295
|
+
url = new URL(raw);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
throw new MicropageError("INVALID_SOURCE", `source.url "${raw}" is not a valid URL.`);
|
|
299
|
+
}
|
|
300
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
301
|
+
let res;
|
|
302
|
+
for (let hop = 0;; hop++) {
|
|
303
|
+
await assertPublicHost(url, lookupHost, hop > 0);
|
|
304
|
+
try {
|
|
305
|
+
res = await fetchImpl(url.toString(), {
|
|
306
|
+
method: "GET",
|
|
307
|
+
// Followed by hand so every hop gets the host check.
|
|
308
|
+
redirect: "manual",
|
|
309
|
+
signal,
|
|
310
|
+
headers: { "User-Agent": `micropage-mcp/${VERSION}`, Accept: "image/*" },
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
|
|
315
|
+
throw new MicropageError("TIMEOUT", `Fetching ${url.host} timed out after ${Math.round(timeoutMs / 1000)}s.`, { cause: err });
|
|
316
|
+
}
|
|
317
|
+
throw new MicropageError("NETWORK", `Fetching ${url.toString()} failed: ${err instanceof Error ? err.message : String(err)}.`, {
|
|
318
|
+
cause: err,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (!REDIRECT_STATUSES.has(res.status))
|
|
322
|
+
break;
|
|
323
|
+
await res.body?.cancel().catch(() => undefined);
|
|
324
|
+
const location = res.headers.get("location");
|
|
325
|
+
if (!location) {
|
|
326
|
+
throw new MicropageError("HTTP", `Fetching ${url.toString()} returned a redirect (HTTP ${res.status}) without a Location.`, {
|
|
327
|
+
status: res.status,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (hop >= MAX_URL_REDIRECTS) {
|
|
331
|
+
throw new MicropageError("INVALID_SOURCE", `source.url redirected more than ${MAX_URL_REDIRECTS} times. Pass the final image URL.`);
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
url = new URL(location, url);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
throw new MicropageError("INVALID_SOURCE", `source.url redirected to an invalid URL (${location}). Nothing was uploaded.`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (!res.ok) {
|
|
341
|
+
await res.body?.cancel().catch(() => undefined);
|
|
342
|
+
throw new MicropageError("HTTP", `Fetching ${url.toString()} failed (HTTP ${res.status}). Check the URL is a public, direct image link.`, {
|
|
343
|
+
status: res.status,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const declared = Number(res.headers.get("content-length"));
|
|
347
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
348
|
+
await res.body?.cancel().catch(() => undefined);
|
|
349
|
+
throw tooLarge(declared, maxBytes);
|
|
350
|
+
}
|
|
351
|
+
// Content-Length can be absent or wrong, so the cap is enforced while reading.
|
|
352
|
+
const chunks = [];
|
|
353
|
+
let total = 0;
|
|
354
|
+
const reader = res.body?.getReader();
|
|
355
|
+
if (reader) {
|
|
356
|
+
try {
|
|
357
|
+
for (;;) {
|
|
358
|
+
const { done, value } = await reader.read();
|
|
359
|
+
if (done)
|
|
360
|
+
break;
|
|
361
|
+
total += value.byteLength;
|
|
362
|
+
if (total > maxBytes) {
|
|
363
|
+
await reader.cancel().catch(() => undefined);
|
|
364
|
+
throw tooLarge(`over ${formatMb(maxBytes)}`, maxBytes);
|
|
365
|
+
}
|
|
366
|
+
chunks.push(value);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
if (isMicropageError(err))
|
|
371
|
+
throw err;
|
|
372
|
+
throw new MicropageError("NETWORK", `Reading ${url.toString()} failed: ${err instanceof Error ? err.message : String(err)}.`, {
|
|
373
|
+
cause: err,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return Buffer.concat(chunks);
|
|
378
|
+
}
|
|
379
|
+
export function sha256Hex(bytes) {
|
|
380
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
381
|
+
}
|
|
382
|
+
export async function listFiles(http, projectId) {
|
|
383
|
+
const data = await http.invokeGet("list-files", {
|
|
384
|
+
project_id: String(projectId),
|
|
385
|
+
});
|
|
386
|
+
const files = Array.isArray(data?.files) ? data.files : [];
|
|
387
|
+
return {
|
|
388
|
+
files,
|
|
389
|
+
total_bytes: typeof data?.total_bytes === "number" ? data.total_bytes : files.reduce((s, f) => s + (f.size_bytes ?? 0), 0),
|
|
390
|
+
// The CLI shows 100 MB when the field is missing.
|
|
391
|
+
space_available_mb: typeof data?.space_available_mb === "number" ? data.space_available_mb : 100,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
export async function getFileUrl(http, fileId) {
|
|
395
|
+
const data = await http.invokeGet("get-file-url", { file_id: fileId });
|
|
396
|
+
if (!data?.url)
|
|
397
|
+
throw new MicropageError("HTTP", "get-file-url returned no URL for this file. Retry, or check the file in the editor.");
|
|
398
|
+
return data.url;
|
|
399
|
+
}
|
|
400
|
+
export async function deleteFile(http, fileId) {
|
|
401
|
+
await http.invoke("delete-file", { file_id: fileId });
|
|
402
|
+
}
|
|
403
|
+
/** Multipart body exactly as the CLI's uploadAssetWithToken builds it. */
|
|
404
|
+
export function buildUploadForm(projectId, filename, bytes, contentHash) {
|
|
405
|
+
const form = new FormData();
|
|
406
|
+
form.append("file", new Blob([new Uint8Array(bytes)], { type: mimeTypeFor(filename) }), filename);
|
|
407
|
+
form.append("project_id", String(projectId));
|
|
408
|
+
form.append("content_hash", contentHash);
|
|
409
|
+
return form;
|
|
410
|
+
}
|
|
411
|
+
export async function uploadFile(http, projectId, filename, bytes, contentHash) {
|
|
412
|
+
try {
|
|
413
|
+
const data = await http.request("POST", http.functionUrl("upload-file"), {
|
|
414
|
+
rawBody: buildUploadForm(projectId, filename, bytes, contentHash),
|
|
415
|
+
});
|
|
416
|
+
if (!data?.file?.id)
|
|
417
|
+
throw new MicropageError("HTTP", "upload-file returned no file record. Call list_files to check whether it landed.");
|
|
418
|
+
return data.file;
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
if (isMicropageError(err) && err.status === 413)
|
|
422
|
+
throw quotaError(err.data, bytes.length);
|
|
423
|
+
throw err;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function quotaError(data, fileSize) {
|
|
427
|
+
const d = (data && typeof data === "object" ? data : {});
|
|
428
|
+
const used = typeof d.current_usage === "number" ? formatMb(d.current_usage) : "unknown";
|
|
429
|
+
const limit = typeof d.max_bytes === "number" ? formatMb(d.max_bytes) : "the plan limit";
|
|
430
|
+
return quotaMessage(used, limit, formatMb(typeof d.file_size === "number" ? d.file_size : fileSize), data);
|
|
431
|
+
}
|
|
432
|
+
function quotaMessage(used, limit, size, data) {
|
|
433
|
+
return new MicropageError("QUOTA_EXCEEDED", `Storage quota exceeded: this project uses ${used} of its ${limit} plan limit and the file is ${size}. ` +
|
|
434
|
+
"Tell the user to delete unused files in the micropage editor or upgrade at https://micropage.sh/pricing, or upload a smaller file.", { status: 413, data });
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The CLI's per-file dedupe (uploadAssetsWithToken): one list-files call;
|
|
438
|
+
* same name + same content_hash is skipped; a changed or NULL hash (rows from
|
|
439
|
+
* before hashing) is deleted and re-uploaded with content_hash. A list-files
|
|
440
|
+
* failure is fatal, as in the CLI, since uploading blind would leave
|
|
441
|
+
* duplicate rows under one name.
|
|
442
|
+
*/
|
|
443
|
+
export async function uploadAsset(http, projectId, filename, bytes) {
|
|
444
|
+
const hash = sha256Hex(bytes);
|
|
445
|
+
const listing = await listFiles(http, projectId);
|
|
446
|
+
// Map semantics match the CLI: with duplicate names, the last row wins.
|
|
447
|
+
const byName = new Map(listing.files.map((f) => [f.filename, f]));
|
|
448
|
+
const existing = byName.get(filename);
|
|
449
|
+
if (existing && existing.content_hash === hash) {
|
|
450
|
+
return { file: existing, url: await getFileUrl(http, existing.id), deduped: true, replaced: false, content_hash: hash };
|
|
451
|
+
}
|
|
452
|
+
// Checked before the delete: upload-file enforces the quota only after the
|
|
453
|
+
// old row is gone, so failing there would lose the old file for nothing.
|
|
454
|
+
const limitBytes = listing.space_available_mb * MB;
|
|
455
|
+
const usedAfterDelete = listing.total_bytes - (existing?.size_bytes ?? 0);
|
|
456
|
+
if (usedAfterDelete + bytes.length > limitBytes) {
|
|
457
|
+
throw quotaMessage(formatMb(listing.total_bytes), `${listing.space_available_mb} MB`, formatMb(bytes.length));
|
|
458
|
+
}
|
|
459
|
+
if (existing)
|
|
460
|
+
await deleteFile(http, existing.id);
|
|
461
|
+
let file;
|
|
462
|
+
try {
|
|
463
|
+
file = await uploadFile(http, projectId, filename, bytes, hash);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
if (existing && isMicropageError(err)) {
|
|
467
|
+
throw new MicropageError(err.code, `${err.message} The previous "${filename}" had already been deleted, so the project has no file by that name now; retry upload_asset.`, { ...(err.status === undefined ? {} : { status: err.status }), data: err.data, cause: err });
|
|
468
|
+
}
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
return { file, url: await getFileUrl(http, file.id), deduped: false, replaced: Boolean(existing), content_hash: hash };
|
|
472
|
+
}
|
|
473
|
+
//# sourceMappingURL=assets.js.map
|